agentmux_srv\backend\wconfig/
loader.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Config file loading, parsing, template merging, and environment expansion.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use super::types::{ConfigError, FullConfigType, WidgetConfigType};
10
11// ---- Default config builder ----
12
13/// Build the initial default configuration with embedded default assets.
14///
15/// Loads the bundled `widgets.json` (from `config/`) at compile time
16/// and populates `FullConfigType.widgets` so the frontend widget bar is populated on startup.
17pub fn build_default_config() -> FullConfigType {
18    let mut config = FullConfigType::default();
19
20    // Embed widgets.json at compile time (equivalent to Go's //go:embed)
21    const WIDGETS_JSON: &str =
22        include_str!("../../config/widgets.json");
23
24    match serde_json::from_str::<HashMap<String, WidgetConfigType>>(WIDGETS_JSON) {
25        Ok(widgets) => {
26            config.widgets = widgets;
27        }
28        Err(e) => {
29            eprintln!("wconfig: failed to parse embedded widgets.json: {}", e);
30        }
31    }
32
33    config
34}
35
36// ---- Config loading helpers ----
37
38/// Read a JSON config file, returning default on missing/error.
39pub fn read_config_file<T: serde::de::DeserializeOwned + Default>(
40    path: &PathBuf,
41) -> (T, Vec<ConfigError>) {
42    let mut errors = Vec::new();
43
44    let content = match std::fs::read_to_string(path) {
45        Ok(c) => c,
46        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (T::default(), errors),
47        Err(e) => {
48            errors.push(ConfigError {
49                file: path.to_string_lossy().to_string(),
50                err: format!("cannot read file: {}", e),
51            });
52            return (T::default(), errors);
53        }
54    };
55
56    // Strip // and /* */ comments so users can annotate settings.json
57    let stripped = json_comments::StripComments::new(content.as_bytes());
58    let mut json_bytes = Vec::new();
59    std::io::Read::read_to_end(&mut std::io::BufReader::new(stripped), &mut json_bytes)
60        .unwrap_or_default();
61
62    // Strip trailing commas before } or ] (common when commented-out lines follow values)
63    let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
64    let clean: Result<T, _> = serde_json::from_str(&json_str);
65
66    match clean {
67        Ok(parsed) => (parsed, errors),
68        Err(e) => {
69            errors.push(ConfigError {
70                file: path.to_string_lossy().to_string(),
71                err: format!("JSON parse error: {}", e),
72            });
73            (T::default(), errors)
74        }
75    }
76}
77
78/// Read `settings.json` as a raw `serde_json::Value::Object`, stripping JSONC comments
79/// and trailing commas. Returns an empty object if the file doesn't exist or can't be parsed.
80pub fn read_settings_raw_jsonc(path: &std::path::Path) -> serde_json::Map<String, serde_json::Value> {
81    if !path.exists() {
82        return serde_json::Map::new();
83    }
84    match std::fs::read_to_string(path) {
85        Ok(content) => parse_jsonc_to_map(&content),
86        Err(_) => serde_json::Map::new(),
87    }
88}
89
90/// Parse a JSONC string (with // comments and trailing commas) into a flat JSON map.
91pub fn parse_jsonc_to_map(content: &str) -> serde_json::Map<String, serde_json::Value> {
92    let stripped_comments = json_comments::StripComments::new(content.as_bytes());
93    let mut json_bytes = Vec::new();
94    std::io::Read::read_to_end(&mut std::io::BufReader::new(stripped_comments), &mut json_bytes)
95        .unwrap_or_default();
96    let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
97    match serde_json::from_str::<serde_json::Value>(&json_str) {
98        Ok(serde_json::Value::Object(map)) => map,
99        _ => serde_json::Map::new(),
100    }
101}
102
103/// Merge user settings into a JSONC template string.
104///
105/// For each user key:
106/// - If the key exists as a commented-out line in the template (`// "key": ...`),
107///   that line is replaced with the uncommented user value.
108/// - If the key is NOT in the template, it is appended before the closing `}`.
109///
110/// The result is always a valid JSONC file with the full template structure intact.
111pub fn merge_into_template(
112    template: &str,
113    user_settings: &serde_json::Map<String, serde_json::Value>,
114) -> String {
115    if user_settings.is_empty() {
116        return template.to_string();
117    }
118
119    let mut remaining: std::collections::HashMap<&str, &serde_json::Value> =
120        user_settings.iter().map(|(k, v)| (k.as_str(), v)).collect();
121    let mut lines: Vec<String> = Vec::new();
122
123    for line in template.lines() {
124        if let Some(key) = extract_commented_setting_key(line) {
125            if let Some(value) = remaining.remove(key) {
126                // Preserve the original indentation
127                let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
128                let val_str = serde_json::to_string(value).unwrap_or_default();
129                lines.push(format!("{}\"{}\": {},", indent, key, val_str));
130                continue;
131            }
132        }
133        lines.push(line.to_string());
134    }
135
136    // Append any remaining user settings not found in the template
137    if !remaining.is_empty() {
138        // Find the last `}` and insert before it
139        if let Some(brace_pos) = lines.iter().rposition(|l| l.trim() == "}") {
140            let mut extra: Vec<String> = Vec::new();
141            extra.push(String::new());
142            extra.push("    // -- User Overrides --".to_string());
143            let mut sorted_keys: Vec<&&str> = remaining.keys().collect();
144            sorted_keys.sort();
145            for key in sorted_keys {
146                let value = remaining[*key];
147                let val_str = serde_json::to_string(value).unwrap_or_default();
148                extra.push(format!("    \"{}\": {},", key, val_str));
149            }
150            for (i, line) in extra.into_iter().enumerate() {
151                lines.insert(brace_pos + i, line);
152            }
153        }
154    }
155
156    let mut result = lines.join("\n");
157    // Ensure file ends with newline
158    if !result.ends_with('\n') {
159        result.push('\n');
160    }
161    result
162}
163
164/// Extract the settings key from a commented-out template line.
165/// Matches lines like: `    // "some:key":   value,`
166/// Returns `Some("some:key")` or `None`.
167fn extract_commented_setting_key(line: &str) -> Option<&str> {
168    let trimmed = line.trim_start();
169    let rest = trimmed.strip_prefix("//")?;
170    let rest = rest.trim_start();
171    let rest = rest.strip_prefix('"')?;
172    let end = rest.find('"')?;
173    Some(&rest[..end])
174}
175
176pub(super) fn strip_trailing_commas(input: &str) -> String {
177    let mut result = String::with_capacity(input.len());
178    let mut chars = input.chars().peekable();
179    let mut in_string = false;
180    let mut last_comma_pos: Option<usize> = None;
181
182    while let Some(ch) = chars.next() {
183        if in_string {
184            result.push(ch);
185            if ch == '\\' {
186                if let Some(&next) = chars.peek() {
187                    result.push(next);
188                    chars.next();
189                }
190            } else if ch == '"' {
191                in_string = false;
192            }
193        } else {
194            match ch {
195                '"' => {
196                    in_string = true;
197                    last_comma_pos = None;
198                    result.push(ch);
199                }
200                ',' => {
201                    last_comma_pos = Some(result.len());
202                    result.push(ch);
203                }
204                '}' | ']' => {
205                    if let Some(pos) = last_comma_pos {
206                        result.replace_range(pos..pos + 1, " ");
207                    }
208                    last_comma_pos = None;
209                    result.push(ch);
210                }
211                c if c.is_whitespace() => {
212                    result.push(ch);
213                }
214                _ => {
215                    last_comma_pos = None;
216                    result.push(ch);
217                }
218            }
219        }
220    }
221    result
222}
223
224/// Replace `$ENV:VAR_NAME` and `$ENV:VAR_NAME:fallback` in a string.
225#[allow(dead_code)]
226pub fn expand_env_vars(s: &str) -> String {
227    let mut result = s.to_string();
228    let mut start = 0;
229
230    while let Some(idx) = result[start..].find("$ENV:") {
231        let abs_idx = start + idx;
232        let rest = &result[abs_idx + 5..];
233
234        // Find the end of the variable reference
235        let end = rest
236            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != ':')
237            .unwrap_or(rest.len());
238
239        let var_spec = &rest[..end];
240
241        // Split on first colon for fallback
242        let (var_name, fallback) = if let Some(colon_idx) = var_spec.find(':') {
243            (&var_spec[..colon_idx], Some(&var_spec[colon_idx + 1..]))
244        } else {
245            (var_spec, None)
246        };
247
248        let value = std::env::var(var_name).unwrap_or_else(|_| {
249            fallback.unwrap_or("").to_string()
250        });
251
252        let full_pattern = format!("$ENV:{}", var_spec);
253        result = result.replacen(&full_pattern, &value, 1);
254        start = abs_idx + value.len();
255    }
256
257    result
258}